feat: outbound file attachments (addresses #355, opt-in, rate-limited)#401
Open
DrVictorChen wants to merge 1 commit intoopenabdev:mainfrom
Open
feat: outbound file attachments (addresses #355, opt-in, rate-limited)#401DrVictorChen wants to merge 1 commit intoopenabdev:mainfrom
DrVictorChen wants to merge 1 commit intoopenabdev:mainfrom
Conversation
…te-limited) Closes openabdev#298. Addresses security items 1-5 in openabdev#355 (the follow-up requirements issue filed against the previous attempt in openabdev#300). ## What this ships Agents running through OpenAB can now include `` markdown in their responses. OpenAB detects the marker, validates the path, uploads the file as a native chat attachment, and strips the marker from the displayed text. The feature is disabled by default — operators must opt in via `[outbound]` in `config.toml`. ## Architecture (adapter-agnostic) - `src/media.rs::extract_outbound_attachments(text, &OutboundConfig)` — platform-independent extraction + validation. - `src/outbound_rate.rs::OutboundRateLimiter` — per-channel sliding 60-second window enforcing `max_per_minute_per_channel`. - `ChatAdapter::send_file_attachments(channel, paths)` — new trait method with a default no-op. `DiscordAdapter` overrides to upload via serenity `CreateAttachment::path()` + `CreateMessage::add_file()`. Slack / future platforms can override as needed. - `AdapterRouter::stream_prompt` — after the final text edit: extract markers → apply rate limit → call `send_file_attachments`. ## Security items from openabdev#355 | # | Item | Implementation | |---|---|---| | 1 | Symlink resolution | `std::fs::canonicalize()` before allowlist check | | 2 | Path traversal (`..`) | same canonicalize resolves `..` components | | 3 | Configurable `allowed_dirs` | `[outbound].allowed_dirs` in config.toml | | 4 | Opt-in (`enabled = false` default) | `OutboundConfig::default().enabled == false` | | 5 | Rate limiting | per-message cap + per-minute sliding-window per channel | Allowlist is canonicalized at send time so macOS's `/tmp → /private/tmp` symlink causes no spurious blocks. Comparison is component-wise via `Path::starts_with`, not string prefix. Reply text for attempts that fail validation (blocked, too large, missing) keeps the marker in the text so the user can see what was tried. ## Tests 8 new in `media::outbound_tests`: - disabled_by_default_is_noop - enabled_extracts_tmp_file - blocks_non_allowlisted_path - blocks_symlink_escape - blocks_path_traversal - respects_custom_allowed_dirs - enforces_max_size_bytes - enforces_max_per_message - strips_multiple_valid_markers 5 new in `outbound_rate::tests`: - admits_up_to_limit_then_blocks_within_window - partial_admit_when_some_remaining_slots - channels_are_independent - zero_requested_or_zero_limit_grants_zero - prunes_entries_older_than_window All 54 tests pass. Clippy clean with `-D warnings -A clippy::manual_contains`. ## Config ```toml [outbound] enabled = false allowed_dirs = ["/tmp/", "/var/folders/"] max_size_bytes = 26214400 # 25 MB max_per_message = 10 max_per_minute_per_channel = 30 ``` Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4 tasks
Contributor
masami-agent
left a comment
There was a problem hiding this comment.
PR Review: #401
Summary
- Problem: Agents can receive files from users but have no native path to send files back to chat. Closes #298, addresses all security requirements from #355.
- Approach:
markdown markers in agent responses → regex extraction → canonicalize + allowlist + size validation → native upload viaChatAdapter::send_file_attachments. Opt-in via[outbound]config. Per-channel sliding-window rate limiter in a separate module. - Risk level: Medium (opens a filesystem → chat channel pathway, but the security model is solid)
Core Assessment
- Problem clearly stated: ✅ (linked #298, #355, supersedes #300)
- Approach appropriate: ✅ (platform-agnostic extraction, trait method with default no-op, standalone rate limiter)
- Alternatives considered: ✅ (PR description references #300 and explains why it was deferred)
- Best approach for now: ✅ (clean separation of concerns, opt-in by default)
Findings
Architecture — well-structured:
media.rs— platform-agnostic extraction + validation (no Discord dependency)outbound_rate.rs— standalone rate limiter withHashMap<String, VecDeque<Instant>>sliding windowChatAdaptertrait —send_file_attachmentswith default no-op (Slack silently drops, no breakage)AdapterRouter— wiring only, no business logic leakage
Security checklist from #355 — all 5 items addressed:
| # | Requirement | Status |
|---|---|---|
| 1 | Symlink resolution (canonicalize) |
✅ |
| 2 | Path traversal (..) |
✅ |
| 3 | Configurable allowed_dirs |
✅ |
| 4 | Opt-in default | ✅ |
| 5 | Rate limiting | ✅ |
| 6 (extra) | Component-wise Path::starts_with |
✅ |
Review Summary
🔴 Blockers
- Branch is 29 commits behind
main— must rebase before merge. TheCargo.lockdiff will likely conflict. This is a hard blocker per repo merge rules.
💬 Questions
canonicalize_allowlist()is called on everyextract_outbound_attachments()invocation — this does filesystem I/O (std::fs::canonicalize) on each agent response. Was caching considered? For the default 2 dirs the cost is negligible, but worth documenting the trade-off or adding a note on why runtime canonicalization was preferred over startup-time caching (e.g. dirs could be mounted/unmounted at runtime).
🔧 Suggested Changes
- Discord upload is sequential per file — the
for path in pathsloop indiscord.rsuploads one file at a time. serenity supports a singleCreateMessagewith multipleadd_file()calls, which would reduce API calls and be more atomic. Not blocking, but a nice improvement. - Config field naming — config uses
max_file_size_mbbut the method ismax_size_bytes(). Considermax_file_size_bytes()to mirror the config field more closely, or add a doc comment on the method noting the MB → bytes conversion.
ℹ️ Info
- The
Cargo.lockchange (0.7.6→0.7.7) is not a version bump by this PR — it fixes a pre-existing inconsistency onmainwhereCargo.tomlis already0.7.7butCargo.lockstill says0.7.6. - Slack adapter correctly inherits the default no-op
send_file_attachments— no changes needed. - Tests are solid: 8 in
media::outbound_tests+ 5 inoutbound_rate::tests, covering happy path, symlink escape, path traversal, custom allowlist, size limit, per-message cap, multi-file stripping, and rate limiter edge cases.
⚪ Nits
config.toml.examplecomment says 25 MB is Discord's limit — Discord's actual limit varies by boost level (8/25/50/100 MB). The default of 25 MB is a reasonable safe choice; the comment could note this nuance.
Verdict
COMMENT — code quality and security model are strong. The branch being 29 commits behind main is a hard blocker. After rebase (watch for Cargo.lock conflicts), this should be a clean approve. Nice work addressing all the #355 security items and splitting the Makefile fix into a separate PR as suggested.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Closes #298. Addresses #355.
Agents running through OpenAB can receive files from users, but have no native path to send files back to the chat. This PR adds an opt-in, config-driven, rate-limited pathway: agents include
markdown in their reply, OpenAB validates the path, uploads the file as a native attachment, and strips the marker from the visible text.Supersedes #300. That PR proposed the same feature but was deferred pending the five security items listed in #355. This PR addresses all five plus one self-imposed hardening.
Changes
New platform-agnostic modules
src/media.rs::extract_outbound_attachments(text, &OutboundConfig)— regex extraction + canonicalization + allowlist + size/count validation.src/outbound_rate.rs::OutboundRateLimiter—HashMap<channel_key, VecDeque<Instant>>60-second sliding window, auto-prunes on each call.ChatAdapter trait
send_file_attachments(channel, paths)method with a default no-op, so adapters that don't support native file upload silently drop the list instead of erroring.DiscordAdapteroverrides to upload via serenityCreateAttachment::path+CreateMessage::add_file. Slack / future platforms just override the method.Wiring
AdapterRouter::stream_prompt— after the final text edit: extract markers → rate-admit →send_file_attachmentswith whatever the limiter granted.Config (opt-in, top-level
[outbound])Security checklist from #355
canonicalize)std::fs::canonicalize()before allowlist check..)allowed_dirs[outbound].allowed_dirsinconfig.tomlOutboundConfig::default().enabled == falsePath::starts_withon canonical paths, notstr::starts_with(blocks/tmp-fake/x)Markers that fail validation stay in the displayed text so the user sees what the agent tried — follows the Hermes Agent fallback-chain pattern rather than silent drop.
Verified
cargo buildpassescargo test— 54 passed (13 new: 8 inmedia::outbound_tests, 5 inoutbound_rate::tests)cargo clippy --all-targets -- -D warnings -A clippy::manual_containsclean/tmp/file, native upload)ln -s /etc/hosts /tmp/x.png→ blocked)→ blocked)Not in this PR
Discord Discussion URL: https://discord.com/channels/1491295327620169908/1493297320622821718
🤖 Generated with Claude Code